Skip to content

feat(e0): refuse an unroutable MIME at the E0 gate instead of dead-lettering it (D104) - #342

Open
fazpu wants to merge 1 commit into
mainfrom
feat/unroutable-mime-preflight
Open

feat(e0): refuse an unroutable MIME at the E0 gate instead of dead-lettering it (D104)#342
fazpu wants to merge 1 commit into
mainfrom
feat/unroutable-mime-preflight

Conversation

@fazpu

@fazpu fazpu commented Aug 31, 2026

Copy link
Copy Markdown
Member

The defect

The conversion router was consulted for the first time inside the convert worker. An upload whose MIME had no route was admitted, hashed, written to immutable raw storage, returned as a normal accepted-not-ready receipt, and only then dead-lettered through UnroutableMimeErrormark_version_failedNonRetryableHandlerError. The caller discovered a terminal state by polling, having been told the upload was accepted.

The waste was knowable at admission — the route table was already in memory — and the caller cannot undo it: identical bytes are the D55 no-op and no API lets them request reprocessing. Recovery is operator work only (remember ops replay reopens the dead-lettered work row, after which a now-routable version converts normally). Each wrong guess costs a durable raw object and an operator ticket.

This is not a media defect. The mechanism keys on absence from the table, so it fired identically for audio, video, images, office documents and archives. The stock table is two text entries.

The placement is the decision

Three ingresses reach E0 without sharing a handler: HTTP POST /ingest, the local MCP ingest tool, and the connector sync worker — the latter two calling the composed port directly. A check on the HTTP handler would have left two of three paths still admitting bytes convert can only dead-letter, while looking fixed.

UploadIngestor is the one object all three write through, which is what the library boundary already requires: ingestion always writes through E0. The check sits in _guard_ingest beside the D74 guard; surfaces only render it.

Two details make that hold rather than merely sound right:

  • routable_mimes is required. A default of "no check" would make the invariant as strong as every composer remembering to pass it. Every deployment has a route table (the settings default is the stock text one), so omission expresses only a mistake.
  • Routability is decided before the D74 per-source query. Both orders are safe since neither writes bytes, but deciding it first avoids an admission query for a request that cannot be accepted and stops a forget-state error from masking a plain "we do not convert that".

Scope of the guarantee

No upload is admitted under a MIME this deployment cannot convertnot no unconvertible version is ever created. The gate reads the declared MIME; convert reads content_objects.mime, which is first-write-wins per content hash. They diverge only when the same bytes return under a different MIME whose first-seen reading has since become unrouted. Closing that costs a content-object lookup on every ingest; the trade is recorded as an open question in the analysis rather than paid or dismissed silently, and the worker's UnroutableMimeError still covers the case.

Surfaces

HTTP renders 415 with the accepted set. map_backend_error maps the typed error for local MCP and the 415 for remote MCP, so an agent gets unsupported_media_type with an actionable next step on both instead of internal_error locally and a flattened engine_client_error remotely. The SDK preserves the structured detail, trusting a code only at its bound status and path.

Breaking change

rememberstack.workers.UploadIngestor now takes a required routable_mimes. Pass frozenset(conversion_routes) where you build it. Deployments configured through REMEMBERSTACK_SELFHOST_CONVERSION_ROUTES need no change. Recorded in /docs/project-status.

Corpus

  • Decision: decisions.md D104
  • Design: plan/designs/e0_files_design.md §3
  • Analysis: plan/analysis/unroutable_mime_preflight.md

Verification

make check clean — ruff, pyright, 1308 passed / 679 skipped.

Tests assert store.writes == 0 on refusal (the exception alone would not prove it — the defect was that bytes became durable), cover both E0 entry points, prove D74 is not consulted for an unroutable input, and lock the gate/router key-set equivalence. Two structural audits guard the shape: record_upload has exactly the two gated callers (counted, not set-collapsed), and no runtime module outside DocumentCatalog inserts document_versions.

Reviewed across five adversarial codex rounds; findings from every round are applied.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KfBUpQuuehJKf2kbNa2D2D

Contributor agreement

Signing on behalf of a legal entity (leave blank if accepting individually):

…ttering it (D104)

The conversion router was consulted for the first time inside the convert
worker. An upload whose MIME had no route was admitted, hashed, written to
immutable raw storage, returned as a normal accepted-not-ready receipt, and
only then dead-lettered through UnroutableMimeError -> mark_version_failed ->
NonRetryableHandlerError. The caller discovered a terminal state by polling,
having been told the upload was accepted.

The waste was knowable at admission: the route table was already in memory.
And the caller cannot undo it — identical bytes are a no-op (D55) and no API
lets them request reprocessing, so recovery is operator work only
(`remember ops replay` reopens the dead-lettered work row, after which a
now-routable version converts normally). Each wrong guess costs a durable raw
object and an operator ticket to discover what a set lookup already knew.

The gate is in E0, not on a surface, and that placement is the decision. Three
ingresses reach E0 without sharing a handler: HTTP POST /ingest, the local MCP
ingest tool, and the connector sync worker, the latter two calling the composed
port directly. A check on the HTTP handler would have left two of three paths
still admitting bytes the convert stage can only dead-letter, while looking
fixed. UploadIngestor is the one object all three write through — the library
boundary already requires that ingestion always writes through E0 — so the
check sits in _guard_ingest beside the D74 guard, and surfaces only render it.

Two details make that placement hold rather than merely sound right. The route
table is a REQUIRED argument: a default of "no check" would make the invariant
as strong as every composer remembering to pass it, and every deployment has a
table (the settings default is the stock text one), so omission expresses only
a mistake. And routability is decided BEFORE the D74 admission query — both
orders are safe since neither writes bytes, but deciding it first avoids an
admission query for a request that cannot be accepted and stops a forget-state
error from masking a plain "we do not convert that".

This is not a media defect. The mechanism keys on absence from the table, so it
fired identically for audio, video, images, office documents and archives.

The table is the only authority and the gate cannot be looser than the worker:
build_conversion_routes refuses composition on an unknown adapter, so a
process's router keys are exactly its configuration's keys, and the gate does
the same exact lookup on the same string. That guarantee is per-configuration,
not global — gate and worker are separately composed, so a route-table change
leaves a window where one has restarted and the other has not, which is why
UnroutableMimeError stays in the worker and stays non-retryable.

Surfaces: HTTP renders 415 with the accepted set. map_backend_error maps the
typed error for local MCP and the 415 for remote MCP, so an agent gets
unsupported_media_type with an actionable next step on both, instead of
internal_error locally and a flattened engine_client_error remotely.

Not in scope: a routing verdict, not a content one. An MP3 labelled text/plain
still passes the gate and fails in the converter, correctly.

Tests assert store.writes == 0 on refusal — the exception alone would not prove
it, since the defect was that bytes became durable. Both E0 entry points are
covered; one test proves D74 is not consulted for an unroutable input; one
locks the gate/router key-set equivalence. Two structural audits guard the
shape itself: that record_upload has exactly one caller, so a future ingress
cannot quietly become a second door, and that routable_mimes keeps no default.

Docs: the API reference documents the 415 and notes MCP and connector sync
refuse the same types; configuration and troubleshooting no longer claim an
unrouted MIME dead-letters on convert, and troubleshooting says how to recover
versions dead-lettered before this existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KfBUpQuuehJKf2kbNa2D2D
@fazpu
fazpu force-pushed the feat/unroutable-mime-preflight branch from d11f145 to 8407fdf Compare September 1, 2026 00:17
@fazpu fazpu changed the title feat(e0): refuse an unroutable MIME at the E0 gate instead of dead-lettering it (D103) feat(e0): refuse an unroutable MIME at the E0 gate instead of dead-lettering it (D104) Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant